fix(security): make transactions server-write-only so a pending row cannot under-quote what it owes (#538) - #539
Merged
Conversation
…annot under-quote what it owes (#538) #528 pinned `status = 'pending'` on the user-scoped INSERT policy, which closed self-declared payment but said nothing about what a legitimately pending row claims it OWES. The Solana settlement columns are exactly that, and both Solana paths verify against them: `lib/payments/solana-reconcile.ts` builds `verifySplitTransfer({ totalBase })` from `settlement_base`, and `/api/payments/solana/tx` builds the wallet's payment request from it. So a student could POST `/rest/v1/transactions` directly with a pending `solana` row for a $49 product carrying `settlement_currency: 'sol', settlement_base: 1`, pay one lamport, and have the verify endpoint confirm the transfer and flip the row to 'successful' on the service-role client — firing `trigger_manage_transactions` and granting the entitlement. Full course access for a fraction of a cent, through the legitimate payment path. Reproduced against a local database; the same row shape also under-quotes `amount`, which understates the school's payout for the providers that charge from their own catalogue. Which columns are load-bearing, since the issue asks before choosing a control: `settlement_base` (the figure compared on-chain), `settlement_currency` (sets decimals, 6 vs 9), `settlement_mint` (which SPL token counts) and `amount` (the legacy fallback, and the payout figure). `settlement_sol_usd` is read by nothing — it is the audit record of the locked quote. The root cause is the INSERT grant, not the columns: nothing in the browser inserts into `transactions`, and both API routes that do already derive every financial field server-side from tenant-scoped `products`/`plans` reads. So their inserts move to the admin client — the pattern #528 applied to `enrollUser` — and the grant is revoked from `authenticated` and `anon`. The #528 policy is kept and tightened to pin the four settlement columns NULL, so the under-quote stays impossible if the grant is ever restored. Rejected: a BEFORE INSERT trigger recomputing `amount` (it would overwrite the admin-confirmed `payment_amount` on manual payments and `grant_free_subscription()`'s zero-amount row), and a SECURITY DEFINER RPC (it cannot fetch the SOL/USD quote, so it would have to accept the rate from the caller — the very value that must not be caller-supplied). Verified against a local database: the under-quoted insert goes from succeeding to `permission denied`; the same insert as service role still succeeds; `grant_free_subscription()` still inserts as `authenticated` through its DEFINER owner; and `/api/payments/checkout` still creates the pending row end-to-end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P8j7YxZRVPT3B4VpENtpng
guillermoscript
marked this pull request as ready for review
July 25, 2026 20:20
This was referenced Jul 25, 2026
guillermoscript
added a commit
that referenced
this pull request
Jul 26, 2026
…ke migration drift detectable (#541) (#552) * fix(security): apply the transactions INSERT lockdown to cloud and make drift detectable (#541) The #538 lockdown (20260725180000) was never applied to the cloud project. Until this commit, `authenticated` still held INSERT on `public.transactions` there and the INSERT policy was the older #528 shape, so a caller could open a pending row quoting `settlement_base: 1` against a priced product, pay that on-chain, and have /api/payments/solana/verify — which verifies against the row's own settlement_base — flip it to successful and grant the entitlement. Applied to cloud (verified by querying the live catalog, not by reading files): - REVOKE INSERT ON transactions FROM authenticated, anon INSERT is now held only by postgres and service_role. - INSERT policy re-created with all four settlement_* IS NULL pins. Nothing depended on the grant: PR #539 had already moved both user-scoped inserts onto createAdminClient(). The two user-scoped .update() calls that remain in the checkout route only touch provider_subscription_id and status, both inside the #528 three-column UPDATE grant, so they are unaffected. WHY IT WAS LOST, AND WHAT NOW CATCHES IT Cloud migration stamps had drifted from repo filenames: four migrations were applied through the MCP apply_migration tool, which stamps a fresh timestamp instead of the filename. That makes "what is missing from cloud?" unanswerable — the re-stamped four read as pending, and the genuinely missing fifth was indistinguishable from them. npm run verify:cloud queries the live database and exits non-zero on drift. It asserts ledger integrity (every repo migration stamped under its own filename; no stamp matching no file) and the #512/#528/#538 payment invariants against catalog state rather than migration text, so a later re-widening fails it too. The rules are split into scripts/lib/verify-cloud-schema-checks.ts and driven from both healthy and drifted fixtures in tests/unit/verify-cloud-schema.test.ts (13 tests). The healthy fixture is the exact state read back from cloud, so the policy-pin matcher is tested against how Postgres actually renders the expression. That is also how the "fails if you re-grant INSERT" criterion is demonstrated: as a repeatable test, rather than by briefly re-opening a write grant on the payments table of the only production database this project has. docs/MIGRATIONS.md now states the push-only rule and why, with the incident table, and reframes the Management API fallback so the schema_migrations stamp reads as the thing that makes it safe rather than optional bookkeeping. Gates: typecheck clean, test:unit 383 passed (370 baseline + 13), eslint clean on new files, build succeeds. Refs #540 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HPFyHjqzSR6Ku1gyWjeQie * fix(db): repair the drifted cloud migration ledger (#541) Applied to cloud. Re-stamps the four migrations that had been applied through the MCP apply_migration tool (which stamps a fresh timestamp instead of the migration's filename) and records 20260725180000, whose DDL was applied earlier in this issue: 20260721120000_add_binance_personal_provider was 20260725213441 20260725110000_transaction_split_snapshot_backstop was 20260725213508 20260725160000_entitlement_gated_enrollment_inserts was 20260725213610 20260725170000_transactions_column_hardening was 20260725192946 20260725180000_transactions_insert_lockdown was absent Each of the four was confirmed genuinely live by querying what its DDL created before being treated as a ledger-only repair, so this re-runs no DDL. The file is committed under the stamp apply_migration assigned it (20260726005843) so the repair is not itself an orphan — the ledger now matches the repo exactly: 171 files, 171 stamps, zero pending, zero orphans. Idempotent: on a fresh `supabase db reset` the four UPDATEs match nothing and the INSERT no-ops, since the CLI has already stamped 20260725180000 by the time this file runs. verify:cloud now reports 10/10 PASS. Gates: typecheck clean, test:unit 383 passed. Refs #540 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HPFyHjqzSR6Ku1gyWjeQie * docs(migrations): note that verify:cloud's ledger checks are branch-relative (#541) The two ledger checks compare cloud against the migrations in the CURRENT checkout, so on a feature branch a migration another in-flight branch has already applied reads as an orphan, and one on this branch not yet applied reads as pending. Neither is drift. Found while confirming #541: PRs #553 (#542) and #554 (#543) had applied 20260726013256, 20260726015858, 20260726100000 and 20260726110000 to cloud, none of which exist on this branch — so verify:cloud run here reports four orphans that are not drift at all. Left as guidance rather than logic. Filtering by "is this stamp on some other branch" would need a remote ref walk and would silently excuse the exact condition the check exists to catch. The orphan detail line now names the possibility and says to re-check on master; the payment-invariant checks are branch-independent and meaningful anywhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HPFyHjqzSR6Ku1gyWjeQie --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
transactionsbecomes server-write-only:authenticatedloses its INSERT grant, and the two API routes that used it (/api/payments/checkout,/api/stripe/create-payment-intent) write on the admin client instead. A student can no longer open a pending row that under-quotes what it owes.Closes #538
Why
#528 closed self-declared payment by pinning
status = 'pending'on the user-scoped INSERT policy. It deliberately said nothing about what a legitimately pending row claims it is owed — and the Solana settlement columns are exactly that. Both Solana paths verify against the row's own figures:lib/payments/solana-reconcile.ts:104→verifySplitTransfer({ totalBase: settlement_base }), reached from/api/payments/solana/verifyand the/api/cron/solana-reconcilebackstopapp/api/payments/solana/tx/route.ts:119→ the payment request the wallet is handedSo a student could
POST /rest/v1/transactionsdirectly with a pendingsolanarow for a $49 product carryingsettlement_currency: 'sol', settlement_base: 1, pay one lamport, and have the verify endpoint confirm the transfer and flip the row tosuccessfulon the service-role client — firingtrigger_manage_transactionsand granting the entitlement. Full course access for a fraction of a cent, through the legitimate payment path. Reproduced against a local database:The same row shape also under-quotes
amount. That does not buy access for Lemon Squeezy / PayPal / Binance — those charge from their own catalogue, andlib/payments/webhook-dispatch.tsnever readsamount— butgetPayoutsOwed()sums it, so an under-quoted row understates what the platform owes the school.Which columns are load-bearing (the issue asks this before choosing a control):
settlement_basesettlement_currencydecimals(6 forusdc, else 9); wrong decimals move the verified amount by 10³settlement_mintsettlement_sol_usdamountsettlement_base IS NULL, and the payout figureThe root cause is the grant, not the columns. Nothing in the browser inserts into
transactions; every legitimate insert is already server-side, and the two API routes are the only reasonauthenticatedheld the grant. Both already deriveamount,currencyandpayment_providerfrom tenant-scopedproducts/plansreads, and the settlement figures fromgetSolUsdPrice(). So:enrollUser.user_idcomes from the verified session andtenant_idfrom thex-tenant-idheader, so the tenant validation CLAUDE.md requires is already in place.REVOKE INSERT ON public.transactions FROM authenticated, anon—POST /rest/v1/transactionsnow fails withpermission denied for table transactions. Combined with transactions RLS restricts rows but not columns — a student can self-insert a 'successful' sale #528's UPDATE column grant,amountand all four settlement columns are unreachable by an untrusted caller on both verbs.GRANT ALL, or someone resolving apermission deniederror the direct way).Two directions from the issue were considered and rejected:
BEFORE INSERTtrigger recomputing the values (the Split snapshot is written at a single call site with no DB backstop (#496 follow-up) #512 pattern) would overwrite legitimate ones — completing a payment request inserts the admin-confirmedpayment_amount, andgrant_free_subscription()inserts a zero-amount row for its plan. It also cannot help with native SOL, whosesettlement_basecomes from a live quote a trigger cannot obtain.SECURITY DEFINERRPC has the same blind spot from the other side: it would have to accept the SOL/USD rate as a caller parameter, and that rate is precisely the value that must not be caller-supplied sincesettlement_baseis derived from it. It moves the vulnerability rather than closing it. The admin-client insert keeps the quote from crossing the client boundary in either direction.How to QA
On a fresh
npm run db:reset(the migration is included in the chain), withnpm run devrunning:/api/payments/checkoutstill creates the pending row priced from the product:npx playwright test transaction-insert-lockdown --workers=1supabase/migrations/rollback/20260725180000_transactions_insert_lockdown.down.sqland re-run-g "INSERT is closed"— both negative tests fail. Re-apply the migration afterwards (ornpm run db:reset).solanaproduct asalice@student.comoncode-academy.lvh.me:3005and confirm the pending row still lands with the correctsettlement_base/settlement_currency/settlement_mint, written by the admin client.successfulrow is still created. Its DEFINER owner holds the INSERT privilege, so the revoke does not reach it.Screenshots / GIF
Not applicable — no user-visible change. This is a database grant plus two server-side client swaps; the checkout UI is byte-identical.
Checklist
npm run typecheckandnpm run test:unitpass (370 unit tests)npm run buildpassestenant_id(or the table genuinely has no such column)Failed to create transactionbranch covers the insertmessages/en.jsonandmessages/es.json— n/a, no UI stringsnpm run db:reset, has RLS policies, andlib/database.types.tswas regenerated — no regeneration needed, the migration adds no columns or typesTest results
npx playwright test transaction-insert-lockdown --workers=1— 16/16 pass across all four projectsparallel-subscription-guard,payment-flows,enrollment-flows,entitlements-overlap,tenant-isolation— all pass on a freshdb:resetmaster— verified by stashing this branch's changes, resetting the database, and re-running:auth-security.spec.ts:23(sign-up form fields) andsubscription-lapse.spec.ts:113(perpetual product entitlement from seed data). Neither touches transaction grants.subscription-lapsealso leaves alice without her seeded subscription, which is what makesparallel-subscription-guardfail if it runs afterwards in the same session.npx eslinton the changed files — 0 errors, 1 pre-existing warning (itemNameunused,create-payment-intent/route.ts:125, untouched by this PR)Notes for the reviewer
create_transaction_for_renewal(sub_id, usr_id, pln_id)(20260126190500_lms_complete.sql:402) is SECURITY INVOKER, granted toanon/authenticated, and inserts intotransactions. It was already unreachable for a user caller — it sets notenant_id, so transactions RLS restricts rows but not columns — a student can self-insert a 'successful' sale #528's policy rejected it — and no application code calls it. After this migration it fails one step earlier, at the grant. Flagged rather than changed; it looks like dead code worth removing separately.handle_new_subscription's EXECUTE grant toauthenticated(20260704190000) was justified byenrollUserinserting as the user. That has been the admin client since transactions RLS restricts rows but not columns — a student can self-insert a 'successful' sale #528, so its comment is now stale, but nothing here depends on the grant either way.